Skip to content

feat(server,cli): health, readiness, status, and preloop status - #172

Open
Bnjoroge1 wants to merge 7 commits into
pr/2-observability-foundationfrom
pr/3-status-endpoints
Open

feat(server,cli): health, readiness, status, and preloop status#172
Bnjoroge1 wants to merge 7 commits into
pr/2-observability-foundationfrom
pr/3-status-endpoints

Conversation

@Bnjoroge1

@Bnjoroge1 Bnjoroge1 commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Public /healthz (503 during shutdown) and /readyz (503 on shutdown, stale critical heartbeats, or a stale sampler snapshot); bearer-auth /api/v1/status and /metrics; a 5s off-lock state sampler producing the OperationalSnapshot; pool preparing, storage, and GitHub state surfaced; OpenAPI docs. CLI preloop status becomes status --json|--limit with a 10-section human renderer and a /readyz engine probe. CLI fixes folded in: UTF-8-safe truncate_reason, the observability handle actually wired into ServerConfig/RunnerPoolConfig (was None), a typed OperationalSnapshot parse gate, and a real 2s shutdown flush.

Part of a stacked series (merge bottom-up):

  1. log hardening
  2. foundation crate
  3. this PR (status endpoints)
  4. HTTP and store metrics
  5. OTLP export
  6. review-fix sweep

Summary by cubic

Expose health, readiness, metrics, and an operational status snapshot, and switch the CLI status to a readable report or raw JSON. Readiness now fails on shutdown, stale critical heartbeats, or a stale sampler; liveness returns 503 during shutdown.

  • Server

    • Adds public /healthz (503 on shutdown) and /readyz (503 with a reason), and bearer-protected /api/v1/status and /metrics in preloop-runner-server; OpenAPI updated.
    • Samples an OperationalSnapshot every 5s off-lock; caches it for /api/v1/status and Prometheus text at /metrics.
    • Wires preloop-observability into AppState; registers heartbeats for the sampler, reaper, and scheduler; gates /readyz when critical tasks are >15s stale or the snapshot is stale.
    • Consolidates pool state via PoolStatus (preparing flag, desired, counts, queue depth, next_job_runs_on, pending tokens); legacy fields remain for compatibility.
    • Reports per-component storage bytes and whether GitHub is configured; seeds an initial snapshot at startup.
    • Re-exports the VM telemetry registry via preloop-vm for shared fleet accounting.
  • CLI and migration

    • Replaces preloop status RUN_ID with preloop status [--json|--limit] in preloop-cli; prints a 10-section human report or raw snapshot JSON; probes /readyz with reason-aware timeouts.
    • Required action: replace scripts that used preloop status RUN_ID with a call to /api/v1/runs/{id} and jq (bearer required), for example: curl -sH "Authorization: Bearer $TOKEN" "$URL/api/v1/runs/{id}" | jq -r .status.
    • Required action: use /readyz for readiness checks; treat /healthz 503 during shutdown as expected.
    • Other fixes: UTF-8–safe reason truncation, typed snapshot parse gate, and a bounded 2s telemetry flush on exit.

Written for commit 825727e. Summary will update on new commits.

Review in cubic

Note

Add health, readiness, status, and /metrics endpoints to runner server

  • Adds GET /readyz (public), GET /api/v1/status (auth), and GET /metrics (auth) to the runner server. A background run_state_sampler updates a cached OperationalSnapshot every 5s in bootstrap.rs.
  • Changes GET /healthz to return 503 during shutdown. GET /readyz returns 503 if critical heartbeats or the status snapshot are stale for >15s in runs.rs.
  • Reworks CLI status to use StatusArgs (--json, --limit). It fetches /api/v1/status and renders a multi-section report instead of single-run status in main.rs.
  • Updates engine bootstrap readiness probe to use /readyz with detailed timeout reasons.
  • Introduces pool_status to RunnerPoolConfig to sync the preparing flag during pool warm-up in lib.rs.
  • Behavioral Change: CLI status removes positional run_id support. GET /healthz returns 503 during shutdown. Engine bootstrap waits on /readyz instead of /healthz.
📊 Macroscope summarized 825727e. 12 files reviewed, 18 issues evaluated, 5 issues filtered, 12 comments posted

🗂️ Filtered Issues

crates/preloop-cli/src/main.rs — 2 comments posted, 5 evaluated, 2 filtered
  • line 1746: pool_status is passed through unchanged even though this function knows pool_enabled and whether provisioning is warm or on-demand. The shared handle is created from PoolSnapshot::default() (mode: PoolMode::Warm), and no code anywhere updates mode. Therefore disabling the warm pool still exposes pool.mode = "warm" instead of on_demand/disabled, misleading the new status endpoint about the pool's operating mode. [ Cross-file consolidated ]
  • line 1746: Passing pool_status here does not actually wire the pool's operational counters into it. In the orchestrator, the only PoolStatus update is set_preparing; there are no calls to set_desired, set_counts, record_provision_failure, or clear_provision_failures anywhere in the repository. Thus a running pool's status snapshot permanently reports the default desired, building, provisioning, idle, busy, paused, and failure values (mostly zero), making the new pool status output materially incorrect. [ Cross-file consolidated ]
crates/preloop-observability/src/status.rs — 2 comments posted, 4 evaluated, 2 filtered
  • line 136: pending_registrations cannot reflect registrations created after startup. The legacy pending map is copied into PoolStatus only once during bootstrap, and repository-wide the only call to PoolStatus::insert_pending is that initial copy while remove_pending has no callers. Because snapshot() derives this field from the disconnected pending_tokens map, /api/v1/status normally continues reporting zero pending registrations while provisioning is underway. [ Out of scope ]
  • line 174: PoolStatus::new creates a second, independent pending-token map rather than sharing the authoritative pending_registrations map. The runner pool continues to insert tokens into the legacy map (there are no callers of PoolStatus::insert_pending outside the one-time startup copy), so tokens created after startup never reach this map and snapshot().pending_registrations incorrectly remains zero while registrations are pending. [ Cross-file consolidated ]
crates/preloop-runner-server/src/bootstrap.rs — 5 comments posted, 6 evaluated, 1 filtered
  • line 193: pool_preparing ORs the legacy flag with pool_status.snapshot().preparing, but bootstrap copies a true legacy value into the default private PoolStatus only once. For backward-compatible callers that provide pool_preparing but no shared pool_status, clearing the atomic after warm-up leaves the copied snapshot permanently true; every reaper pass then clears queued_at, so unmatched Linux jobs are never failed after the grace period and remain queued indefinitely. [ Out of scope ]

Bnjoroge1 and others added 7 commits August 20, 2026 21:32
Entire-Checkpoint: 01M0GMAK0GBF4MWHPY34DVRZC3
Entire-Checkpoint: 01M0GMKC9BKEHVJBA4DXMRPFX6
Entire-Checkpoint: 01M0GZ9EAD3TT4DGQZDY03PBBX
preloop-vm and preloop-orchestrator share the fleet registry through
preloop-observability instead of owning their own, avoiding a circular
dependency. The host sampler itself is a stub until the cgroup/process
parser lands.

Entire-Checkpoint: 01M0GZN3CAJJM3NXHQ4Q9QHKC4
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@coderabbitai

coderabbitai Bot commented Aug 21, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 6ff15444-71dc-4e7f-ab3d-e2cd5ccdfea2

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@Bnjoroge1 Bnjoroge1 mentioned this pull request Aug 21, 2026
9 tasks
runs_req = runs_req.bearer_auth(token);
}
let runs: Vec<serde_json::Value> = match runs_req.send().await {
Ok(r) if r.status().is_success() => r.json().await.unwrap_or_default(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium src/main.rs:2935

A malformed or truncated successful /api/v1/runs response is silently rendered as No runs found., falsely reporting an empty run history. unwrap_or_default() discards the body-read/JSON decoding error; handle that error explicitly, either by propagating it or emitting the same warning used by the other runs-fetch failure branches.

-        Ok(r) if r.status().is_success() => r.json().await.unwrap_or_default(),
+        Ok(r) if r.status().is_success() => match r.json().await {
+            Ok(runs) => runs,
+            Err(e) => {
+                eprintln!("[warn] runs table unavailable: {e}");
+                Vec::new()
+            }
+        },
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/preloop-cli/src/main.rs around line 2935:

A malformed or truncated successful `/api/v1/runs` response is silently rendered as `No runs found.`, falsely reporting an empty run history. `unwrap_or_default()` discards the body-read/JSON decoding error; handle that error explicitly, either by propagating it or emitting the same warning used by the other runs-fetch failure branches.

signal.store(true, std::sync::atomic::Ordering::Release);
}
if let Some(ps) = &self.config.pool_status {
ps.set_preparing(true);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium src/lib.rs:2263

When pool startup fails after set_preparing(true), PoolStatus.preparing remains true permanently, so the shared status reports that the pool is still warming even after the caller handles or retries the error. The ? returns from ensure_host_externals, artifact preparation, stale-machine cleanup, and golden setup bypass the only set_preparing(false) call; clear the flag on every exit, for example with a scope guard.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/preloop-orchestrator/src/lib.rs around line 2263:

When pool startup fails after `set_preparing(true)`, `PoolStatus.preparing` remains `true` permanently, so the shared status reports that the pool is still warming even after the caller handles or retries the error. The `?` returns from `ensure_host_externals`, artifact preparation, stale-machine cleanup, and golden setup bypass the only `set_preparing(false)` call; clear the flag on every exit, for example with a scope guard.

return (StatusCode::SERVICE_UNAVAILABLE, Json(body)).into_response();
}
// Check critical heartbeats freshness (>15s stale is 3 intervals of 5s sampler)
if let Some(stale) = shared

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium src/runs.rs:24

readyz returns 200 even when required critical background tasks have never registered or have already exited. any_critical_stale returns None for an empty registry, and HeartbeatHandle deregistration removes exited tasks, so this check cannot distinguish healthy tasks from absent tasks. Require the expected critical tasks to be registered and running before reporting readiness.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/preloop-runner-server/src/runs.rs around line 24:

`readyz` returns 200 even when required critical background tasks have never registered or have already exited. `any_critical_stale` returns `None` for an empty registry, and `HeartbeatHandle` deregistration removes exited tasks, so this check cannot distinguish healthy tasks from absent tasks. Require the expected critical tasks to be registered and running before reporting readiness.

heartbeat.beat("state_sampler");
let mut interval = tokio::time::interval(Duration::from_secs(5));
// Immediate sample then every 5s.
interval.tick().await;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium src/bootstrap.rs:565

The sampler skips its documented immediate sample, so /api/v1/status and /metrics serve the seeded all-zero snapshot for the first 5 seconds even when recovered state contains queued/running jobs or runners. interval.tick().await consumes Tokio's initial immediate tick before the loop; remove it so the select! handles that first tick and publishes the recovered state immediately.

-    interval.tick().await;
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/preloop-runner-server/src/bootstrap.rs around line 565:

The sampler skips its documented immediate sample, so `/api/v1/status` and `/metrics` serve the seeded all-zero snapshot for the first 5 seconds even when recovered state contains queued/running jobs or runners. `interval.tick().await` consumes Tokio's initial immediate tick before the loop; remove it so the `select!` handles that first tick and publishes the recovered state immediately.

Comment on lines +455 to +458
} else if pool_snapshot.preparing {
// Temporarily unclaimable while pool prepares.
(0, queue_jobs.len() as u32)
} else {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium src/bootstrap.rs:455

When pool_snapshot.preparing is true, the status snapshot reports every queued job as unclaimable, including jobs that an already registered matching runner can claim. The preparation branch bypasses the runner-label check; let claimability be determined by currently eligible runners instead.

-    } else if pool_snapshot.preparing {
-        // Temporarily unclaimable while pool prepares.
-        (0, queue_jobs.len() as u32)
-    } else {
+    } else {
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/preloop-runner-server/src/bootstrap.rs around lines 455-458:

When `pool_snapshot.preparing` is true, the status snapshot reports every queued job as unclaimable, including jobs that an already registered matching runner can claim. The preparation branch bypasses the runner-label check; let claimability be determined by currently eligible runners instead.

// must never run under the state lock.
let component = |name: &str, path: std::path::PathBuf| StorageComponent {
store: name.to_string(),
bytes: std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium src/bootstrap.rs:530

StorageSnapshot.components reports only the directory entry metadata size for cache and artifacts, not the bytes stored beneath them, so the CLI shows tiny and misleading storage totals even when those stores contain large data. Replace the direct metadata(...).len() calculation with a recursive directory-size calculation (while retaining the file metadata path for database).

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/preloop-runner-server/src/bootstrap.rs around line 530:

`StorageSnapshot.components` reports only the directory entry metadata size for `cache` and `artifacts`, not the bytes stored beneath them, so the CLI shows tiny and misleading storage totals even when those stores contain large data. Replace the direct `metadata(...).len()` calculation with a recursive directory-size calculation (while retaining the file metadata path for `database`).

fn default() -> Self {
Self {
mode: PoolMode::Warm,
desired: 0,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium src/status.rs:143

Active pools report desired, runner counts, provision failures, and last_transition_at as their default values (mostly zero or None) in /api/v1/status, even when runners are idle or busy. PoolStatus is only updated via set_preparing; the setters for these fields are never called, so passing pool_status here does not populate operational counters. Update it from the pool lifecycle transitions, including failure recording and clearing.

Also found in 1 other location(s)

crates/preloop-cli/src/main.rs:1746

Passing pool_status here does not actually wire the pool's operational counters into it. In the orchestrator, the only PoolStatus update is set_preparing; there are no calls to set_desired, set_counts, record_provision_failure, or clear_provision_failures anywhere in the repository. Thus a running pool's status snapshot permanently reports the default desired, building, provisioning, idle, busy, paused, and failure values (mostly zero), making the new pool status output materially incorrect.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/preloop-observability/src/status.rs around line 143:

Active pools report `desired`, runner counts, provision failures, and `last_transition_at` as their default values (mostly zero or `None`) in `/api/v1/status`, even when runners are idle or busy. `PoolStatus` is only updated via `set_preparing`; the setters for these fields are never called, so passing `pool_status` here does not populate operational counters. Update it from the pool lifecycle transitions, including failure recording and clearing.

Also found in 1 other location(s):
- crates/preloop-cli/src/main.rs:1746 -- Passing `pool_status` here does not actually wire the pool's operational counters into it. In the orchestrator, the only `PoolStatus` update is `set_preparing`; there are no calls to `set_desired`, `set_counts`, `record_provision_failure`, or `clear_provision_failures` anywhere in the repository. Thus a running pool's status snapshot permanently reports the default `desired`, `building`, `provisioning`, `idle`, `busy`, `paused`, and failure values (mostly zero), making the new pool status output materially incorrect.

impl Default for PoolSnapshot {
fn default() -> Self {
Self {
mode: PoolMode::Warm,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium src/status.rs:142

/api/v1/status reports pool.mode as "warm" for disabled, on-demand, and external pools. PoolSnapshot::default() hard-codes mode to PoolMode::Warm, and the shared pool_status is never updated with the configured mode; initialize or update it from pool_enabled and the selected provisioning mode.

Also found in 1 other location(s)

crates/preloop-cli/src/main.rs:1746

pool_status is passed through unchanged even though this function knows pool_enabled and whether provisioning is warm or on-demand. The shared handle is created from PoolSnapshot::default() (mode: PoolMode::Warm), and no code anywhere updates mode. Therefore disabling the warm pool still exposes pool.mode = &#34;warm&#34; instead of on_demand/disabled, misleading the new status endpoint about the pool's operating mode.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/preloop-observability/src/status.rs around line 142:

`/api/v1/status` reports `pool.mode` as `"warm"` for disabled, on-demand, and external pools. `PoolSnapshot::default()` hard-codes `mode` to `PoolMode::Warm`, and the shared `pool_status` is never updated with the configured mode; initialize or update it from `pool_enabled` and the selected provisioning mode.

Also found in 1 other location(s):
- crates/preloop-cli/src/main.rs:1746 -- `pool_status` is passed through unchanged even though this function knows `pool_enabled` and whether provisioning is warm or on-demand. The shared handle is created from `PoolSnapshot::default()` (`mode: PoolMode::Warm`), and no code anywhere updates `mode`. Therefore disabling the warm pool still exposes `pool.mode = "warm"` instead of `on_demand`/`disabled`, misleading the new status endpoint about the pool's operating mode.

pool_preparing: None,
listen,
pool_status: None,
observability: None,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium src/main.rs:134

The standalone serve path passes None for observability, so AppState uses Observability::noop() and /api/v1/status, /metrics, heartbeat tracking, and limit telemetry are disconnected from the configured process handle. Pass the constructed handle into ServerConfig instead.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/preloop-runner-server/src/main.rs around line 134:

The standalone `serve` path passes `None` for `observability`, so `AppState` uses `Observability::noop()` and `/api/v1/status`, `/metrics`, heartbeat tracking, and limit telemetry are disconnected from the configured process handle. Pass the constructed handle into `ServerConfig` instead.

pool_enabled,
pool_preparing.clone(),
pending_registrations.clone(),
pool_status.clone(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium src/main.rs:1357

/api/v1/status reports pool.pending_registrations as zero while provision tokens are outstanding. pool_status and pending_registrations use independent maps; the server only copies the legacy map into PoolStatus once at startup, while provisioning later inserts and removes tokens only in RunnerPoolConfig::pending_registrations. Share the same token storage or synchronize every insertion and removal with PoolStatus.

Also found in 1 other location(s)

crates/preloop-observability/src/status.rs:174

PoolStatus::new creates a second, independent pending-token map rather than sharing the authoritative pending_registrations map. The runner pool continues to insert tokens into the legacy map (there are no callers of PoolStatus::insert_pending outside the one-time startup copy), so tokens created after startup never reach this map and snapshot().pending_registrations incorrectly remains zero while registrations are pending.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/preloop-cli/src/main.rs around line 1357:

`/api/v1/status` reports `pool.pending_registrations` as zero while provision tokens are outstanding. `pool_status` and `pending_registrations` use independent maps; the server only copies the legacy map into `PoolStatus` once at startup, while provisioning later inserts and removes tokens only in `RunnerPoolConfig::pending_registrations`. Share the same token storage or synchronize every insertion and removal with `PoolStatus`.

Also found in 1 other location(s):
- crates/preloop-observability/src/status.rs:174 -- `PoolStatus::new` creates a second, independent pending-token map rather than sharing the authoritative `pending_registrations` map. The runner pool continues to insert tokens into the legacy map (there are no callers of `PoolStatus::insert_pending` outside the one-time startup copy), so tokens created after startup never reach this map and `snapshot().pending_registrations` incorrectly remains zero while registrations are pending.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant